From: Florian Müllner Date: Sun, 11 May 2025 13:02:51 +0000 (+0200) Subject: extensions-tool: Add upload command X-Git-Tag: archive/raspbian/50.5-1+rpi1~1^2~124^2^2~36 X-Git-Url: https://dgit.raspbian.org/%22http://www.example.com/cgi/%22/%22http:/www.example.com/cgi/%22?a=commitdiff_plain;h=679c29e4d7386941141155a5e1043ca2ee982a1f;p=gnome-shell.git extensions-tool: Add upload command Add a new command for uploading new extension versions to https://extensions.gnome.org. This is particularly useful where the web interface is inconvenient or not available, for example when uploading multiple extensions or from a tag pipeline in CI. Part-of: --- diff --git a/po/POTFILES.in b/po/POTFILES.in index 7df17181f..9acd2f1d2 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -111,6 +111,7 @@ subprojects/extensions-tool/src/command-pack.c subprojects/extensions-tool/src/command-prefs.c subprojects/extensions-tool/src/command-reset.c subprojects/extensions-tool/src/command-uninstall.c +subprojects/extensions-tool/src/command-upload.c subprojects/extensions-tool/src/main.c subprojects/extensions-tool/src/templates/00-plain.desktop.in subprojects/extensions-tool/src/templates/indicator.desktop.in diff --git a/subprojects/extensions-tool/completion/bash/gnome-extensions b/subprojects/extensions-tool/completion/bash/gnome-extensions index 05cd03926..cf2e0a8c0 100644 --- a/subprojects/extensions-tool/completion/bash/gnome-extensions +++ b/subprojects/extensions-tool/completion/bash/gnome-extensions @@ -5,7 +5,7 @@ ################################################################################ __gnome_extensions() { - local commands="version enable disable reset info install show list create pack prefs uninstall" + local commands="version enable disable reset info install show list create pack prefs uninstall upload" local COMMAND=${COMP_WORDS[1]} _init_completion -s || return @@ -68,7 +68,7 @@ __gnome_extensions() { ;; esac ;; - install) + install|upload) if [[ $cur != -* ]] then _filedir zip diff --git a/subprojects/extensions-tool/man/gnome-extensions.rst b/subprojects/extensions-tool/man/gnome-extensions.rst index eca6abcde..cfef78931 100644 --- a/subprojects/extensions-tool/man/gnome-extensions.rst +++ b/subprojects/extensions-tool/man/gnome-extensions.rst @@ -25,6 +25,7 @@ SYNOPSIS | **gnome-extensions** pack [*OPTION*...] | **gnome-extensions** install [*OPTION*...] *PACK* | **gnome-extensions** uninstall *UUID* +| **gnome-extensions** upload [*OPTION*...] *PACK*... DESCRIPTION ----------- @@ -243,6 +244,33 @@ COMMANDS Uninstalls the extension identified by *UUID*. +``upload`` [*OPTION*...] *PACK*... + + Upload one or more extension packs to https://extensions.gnome.org. + + If login credentials are required, they can be entered interactively + when prompted. This is generally preferred over the ``--password`` + or ``--password-file`` options, which risk exposing the password in + logs, the environment or filesystem (depending on how it was provided). + + **Options** + + ``--accept-tos`` + + Accept the terms of service at https://extensions.gnome.org/upload/ + + ``--user``\ =\ *USER* + + Username to log into https://extensions.gnome.org + + ``--password``\ =\ *PASSWORD* + + Password to log into https://extensions.gnome.org + + ``--password-file``\ =\ *FILE* + + File to read https://extensions.gnome.org password from + EXIT STATUS ----------- diff --git a/subprojects/extensions-tool/meson.build b/subprojects/extensions-tool/meson.build index 9d8c9f066..98006336a 100644 --- a/subprojects/extensions-tool/meson.build +++ b/subprojects/extensions-tool/meson.build @@ -31,6 +31,7 @@ gio_dep = dependency('gio-2.0', version: gio_req) gio_unix_dep = dependency('gio-unix-2.0', version: gio_req) autoar_dep = dependency('gnome-autoar-0') json_dep = dependency('json-glib-1.0') +soup_dep = dependency('libsoup-3.0') cc = meson.get_compiler('c') diff --git a/subprojects/extensions-tool/src/command-upload.c b/subprojects/extensions-tool/src/command-upload.c new file mode 100644 index 000000000..d427f7cdb --- /dev/null +++ b/subprojects/extensions-tool/src/command-upload.c @@ -0,0 +1,487 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * SPDX-FileCopyrightText: 2025 Florian Müllner + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include + +#include +#include +#include + +#include +#include + +#include "commands.h" +#include "common.h" +#include "config.h" + +#define EGO_URL_BASE "https://extensions.gnome.org/api/v1" + +static char * +get_cached_token_filename () { + return g_build_filename (g_get_user_cache_dir (), + "gnome-extensions", + "auth-token", + NULL); +} + +static char * +get_cached_token () +{ + g_autoptr (GDateTime) expiry = NULL; + g_autoptr (GDateTime) now = NULL; + g_autoptr (GTimeZone) tz = NULL; + g_autoptr (JsonParser) parser = NULL; + g_autofree char *token_filename = NULL; + JsonObject *root_obj = NULL; + const char *date_str; + + token_filename = get_cached_token_filename (); + parser = json_parser_new_immutable (); + if (!json_parser_load_from_file (parser, token_filename, NULL)) + return NULL; + + root_obj = json_node_get_object (json_parser_get_root (parser)); + if (!json_object_has_member (root_obj, "token")) + return NULL; + + if (!json_object_has_member (root_obj, "expiry")) + return NULL; + + date_str = json_object_get_string_member (root_obj, "expiry"); + + tz = g_time_zone_new_utc (); + expiry = g_date_time_new_from_iso8601 (date_str, tz); + now = g_date_time_new_now (tz); + if (g_date_time_difference (expiry, now) <= 0) + return NULL; + + return g_strdup (json_object_get_string_member (root_obj, "token")); +} + +static gboolean +response_cache_token (JsonObject *object) +{ + g_autoptr (JsonGenerator) generator = NULL; + JsonNode *token_node; + g_autofree char *token_filename = NULL; + g_autofree char *token_dirname = NULL; + + if (!json_object_has_member (object, "token")) + return FALSE; + + token_node = json_object_get_member (object, "token"); + + generator = json_generator_new (); + json_generator_set_root (generator, token_node); + + token_filename = get_cached_token_filename (); + token_dirname = g_path_get_dirname (token_filename); + g_mkdir_with_parents (token_dirname, 0755); + + return json_generator_to_file (generator, token_filename, NULL); +} + +static const char * +response_get_token (JsonObject *object, + GError **error) +{ + JsonObject *token_obj = NULL; + const char *token = NULL; + + if (!json_object_has_member (object, "token")) + goto out; + + token_obj = json_object_get_object_member (object, "token"); + + if (!json_object_has_member (token_obj, "token")) + goto out; + + token = json_object_get_string_member (token_obj, "token"); + +out: + if (token == NULL) + { + const char *msg; + + if (token_obj && json_object_has_member (token_obj, "error")) + msg = json_object_get_string_member (token_obj, "error"); + else + msg = "Invalid token"; + + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, msg); + + return NULL; + } + + return token; +} + +static const char * +response_get_detail (JsonObject *object) +{ + if (!json_object_has_member (object, "detail")) + return NULL; + + return json_object_get_string_member (object, "detail"); +} + +static JsonObject * +session_send_message (SoupSession *session, + SoupMessage *message, + GError **error) +{ + g_autoptr (GBytes) bytes = NULL; + g_autoptr (JsonParser) parser = NULL; + const char *data; + gsize size; + + bytes = soup_session_send_and_read (session, + message, + NULL, + error); + + if (bytes == NULL) + return NULL; + + parser = json_parser_new_immutable (); + data = g_bytes_get_data (bytes, &size); + if (!json_parser_load_from_data (parser, data, size, error)) + return NULL; + + return json_node_dup_object (json_parser_get_root (parser)); +} + +static gboolean +read_password_file (const char *filename, + char **password, + GError **error) +{ + g_autoptr (GDataInputStream) istream = NULL; + char *line = NULL; + + if (strcmp (filename, "-") == 0) + { + g_autoptr (GInputStream) stdin = NULL; + + stdin = g_unix_input_stream_new (STDIN_FILENO, FALSE); + istream = g_data_input_stream_new (stdin); + } + else + { + g_autoptr (GFile) file = NULL; + g_autoptr (GFileInputStream) fstream = NULL; + + file = g_file_new_for_commandline_arg (filename); + fstream = g_file_read (file, NULL, error); + if (fstream == NULL) + return FALSE; + + istream = g_data_input_stream_new (G_INPUT_STREAM (fstream)); + } + + line = g_data_input_stream_read_line_utf8 (istream, NULL, NULL, error); + if (line == NULL) + { + if (error != NULL && *error == NULL) + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, "File is empty"); + return FALSE; + } + + *password = g_strdelimit (line, "\n", '\0'); + + return TRUE; +} + +static void +ensure_credentials (char **user, + char **password) +{ + g_autoptr (GInputStream) stdin = NULL; + g_autoptr (GDataInputStream) istream = NULL; + + g_return_if_fail (user != NULL); + g_return_if_fail (password != NULL); + + if (*user != NULL && *password != NULL) + return; + + stdin = g_unix_input_stream_new (STDIN_FILENO, FALSE); + istream = g_data_input_stream_new (stdin); + + g_print (_("Login to extensions.gnome.org\n")); + + if (*user == NULL) + { + char *line = NULL; + + while (line == NULL) + { + g_print ("%s: ", _("Username")); + + line = g_data_input_stream_read_line_utf8 (istream, NULL, NULL, NULL); + } + *user = g_strdelimit (line, "\n", '\0'); + } + + if (*password == NULL) + { + struct termios term_attr; + int old_flags; + char *line = NULL; + + tcgetattr (STDIN_FILENO, &term_attr); + old_flags = term_attr.c_lflag; + + while (line == NULL) + { + g_print ("%s: ", _("Password")); + + term_attr.c_lflag &= ~ECHO; + if (tcsetattr (STDIN_FILENO, TCSAFLUSH, &term_attr) != 0) + g_print (_("Warning! Password will be echoed")); + + line = g_data_input_stream_read_line_utf8 (istream, NULL, NULL, NULL); + + term_attr.c_lflag = old_flags; + tcsetattr (STDIN_FILENO, TCSAFLUSH, &term_attr); + + g_print ("\n"); + } + *password = g_strdelimit (line, "\n", '\0'); + } +} + +static char * +session_get_login_token (SoupSession *session, + char *user, + char *password, + GError **error) +{ + g_autoptr (SoupMessage) message = NULL; + g_autoptr (JsonObject) response = NULL; + const char *token = NULL; + SoupStatus status; + + message = soup_message_new_from_encoded_form ("POST", + EGO_URL_BASE "/accounts/login/", + soup_form_encode ("login", user, + "password", password, + NULL)); + + response = session_send_message (session, message, error); + + status = soup_message_get_status (message); + if (status != SOUP_STATUS_OK) + { + const char *detail = response_get_detail (response); + + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + detail ? detail : soup_status_get_phrase (status)); + return NULL; + } + + token = response_get_token (response, error); + + if (token == NULL) + return NULL; + + if (!response_cache_token (response)) + g_warning ("Failed to cache login token"); + + return g_strdup (token); +} + +static gboolean +session_upload_file (SoupSession *session, + GFile *file, + const char *token, + GError **error) +{ + g_autoptr (SoupMessage) message = NULL; + g_autoptr (SoupMultipart) multipart = NULL; + g_autoptr (JsonObject) response = NULL; + g_autoptr (GBytes) contents = NULL; + g_autofree char *path = NULL; + g_autofree char *auth_token = NULL; + SoupStatus status; + + contents = g_file_load_bytes (file, NULL, NULL, error); + if (contents == NULL) + return FALSE; + + path = g_file_get_path (file); + + multipart = soup_multipart_new ("multipart/form-data"); + soup_multipart_append_form_file (multipart, + "source", path, + "application/zip", + contents); + soup_multipart_append_form_string (multipart, + "shell_license_compliant", "true"); + soup_multipart_append_form_string (multipart, + "tos_compliant", "true"); + + message = soup_message_new_from_multipart (EGO_URL_BASE "/extensions", + multipart); + + auth_token = g_strdup_printf ("Token %s", token); + soup_message_headers_append (soup_message_get_request_headers (message), + "Authorization", + auth_token); + + response = session_send_message (session, message, error); + if (response == NULL) + return FALSE; + + status = soup_message_get_status (message); + if (status != SOUP_STATUS_CREATED) + { + const char *detail = response_get_detail (response); + + g_set_error (error, G_IO_ERROR, G_IO_ERROR_FAILED, + detail ? detail : soup_status_get_phrase (status)); + return FALSE; + } + + return TRUE; +} + +static gboolean +upload_extensions (char *user, + char *password, + const char **filenames) +{ + g_autoptr (SoupSession) session = NULL; + g_autofree char *token = NULL; + g_autoptr (GError) error = NULL; + gboolean success = TRUE; + + session = soup_session_new (); + + token = get_cached_token (); + if (token == NULL) + { + ensure_credentials (&user, &password); + token = session_get_login_token (session, user, password, &error); + } + + if (token == NULL) + { + if (error != NULL) + g_printerr ("Failed to get login token: %s\n", error->message); + return FALSE; + } + + for (const char **f = filenames; *f; f++) + { + const char *filename = *f; + g_autoptr (GFile) file = NULL; + g_autoptr (GError) local_error = NULL; + + file = g_file_new_for_commandline_arg (filename); + if (!session_upload_file (session, file, token, &local_error)) + { + success = FALSE; + g_printerr ("Failed to upload %s: %s\n", filename, local_error->message); + } + } + + return success; +} + +int +handle_upload (int argc, char *argv[], gboolean do_help) +{ + g_autoptr (GOptionContext) context = NULL; + g_autoptr (GError) error = NULL; + g_auto(GStrv) filenames = NULL; + g_autofree char *user = NULL; + g_autofree char *password = NULL; + g_autofree char *password_file = NULL; + gboolean tos_accepted = FALSE; + GOptionEntry entries[] = { + { .long_name = "user", .short_name = 'u', + .arg_description = _("USERNAME"), + .arg = G_OPTION_ARG_STRING, .arg_data = &user, + .description = _("Username to log into https://extensions.gnome.org") }, + { .long_name = "password", .short_name = 'p', + .arg_description = _("PASSWORD"), + .arg = G_OPTION_ARG_STRING, .arg_data = &password, + .description = _("Password to log into https://extensions.gnome.org") }, + { .long_name = "password-file", .short_name = 'P', + .arg_description = _("FILE"), + .arg = G_OPTION_ARG_FILENAME, .arg_data = &password_file, + .description = _("Read https://extensions.gnome.org password from file, use \"-\" for stdin") }, + { .long_name = "accept-tos", + .arg = G_OPTION_ARG_NONE, .arg_data = &tos_accepted, + .description = _("Accept the terms of service at https://extensions.gnome.org/upload/") }, + { .long_name = G_OPTION_REMAINING, + .arg_description = "FILE…", + .arg = G_OPTION_ARG_FILENAME_ARRAY, .arg_data = &filenames }, + { NULL } + }; + + g_set_prgname ("gnome-extensions upload"); + + context = g_option_context_new (NULL); + g_option_context_set_help_enabled (context, FALSE); + g_option_context_set_summary (context, _("Upload new extension versions")); + g_option_context_add_main_entries (context, entries, GETTEXT_PACKAGE); + g_option_context_add_group (context, get_option_group()); + + if (do_help) + { + show_help (context, NULL); + return 0; + } + + if (!g_option_context_parse (context, &argc, &argv, &error)) + { + show_help (context, error->message); + return 1; + } + + if (password != NULL && password_file != NULL) + { + show_help (context, _("Only one of --password and --password-file can be used")); + return 1; + } + + if (!tos_accepted) + { + show_help (context, _("You must accept the terms of service to upload extensions")); + return 1; + } + + if (filenames == NULL) + { + show_help (context, _("No files given")); + return 1; + } + + if (password_file != NULL) + { + if (!read_password_file (password_file, &password, &error)) + { + g_printerr ("Failed to read %s: %s\n", password_file, error->message); + return 1; + } + } + + return upload_extensions (user, password, (const char **)filenames) ? 0 : 2; +} diff --git a/subprojects/extensions-tool/src/commands.h b/subprojects/extensions-tool/src/commands.h index 618e8413d..97e88e46a 100644 --- a/subprojects/extensions-tool/src/commands.h +++ b/subprojects/extensions-tool/src/commands.h @@ -34,5 +34,6 @@ int handle_create (int argc, char *argv[], gboolean do_help); int handle_pack (int argc, char *argv[], gboolean do_help); int handle_install (int argc, char *argv[], gboolean do_help); int handle_uninstall (int argc, char *argv[], gboolean do_help); +int handle_upload (int argc, char *argv[], gboolean do_help); G_END_DECLS diff --git a/subprojects/extensions-tool/src/main.c b/subprojects/extensions-tool/src/main.c index baeee9d0c..4e4473afe 100644 --- a/subprojects/extensions-tool/src/main.c +++ b/subprojects/extensions-tool/src/main.c @@ -420,6 +420,8 @@ main (int argc, char *argv[]) return handle_install (argc, argv, do_help); else if (g_str_equal (command, "uninstall")) return handle_uninstall (argc, argv, do_help); + else if (g_str_equal (command, "upload")) + return handle_upload (argc, argv, do_help); else usage (); diff --git a/subprojects/extensions-tool/src/meson.build b/subprojects/extensions-tool/src/meson.build index a855fef97..53ded1b19 100644 --- a/subprojects/extensions-tool/src/meson.build +++ b/subprojects/extensions-tool/src/meson.build @@ -19,6 +19,7 @@ sources = [ 'command-prefs.c', 'command-reset.c', 'command-uninstall.c', + 'command-upload.c', 'main.c' ] @@ -32,6 +33,6 @@ resources = gnome.compile_resources('resources', executable('gnome-extensions', sources, resources, - dependencies: [gio_dep, gio_unix_dep, autoar_dep, json_dep], + dependencies: [gio_dep, gio_unix_dep, autoar_dep, json_dep, soup_dep], install: true )